Using never type for exhaustive checks

Chat GPT 5 Answer

이 글에서는

  1. never 타입이 뭔지,

  2. switch에서 어떻게 모든 경우의 수를 처리하도록 강제하는지,

  3. 현업(예: NestJS, DDD/이벤트소싱)에서 바로 쓸 수 있는 변형/확장 패턴,

  4. ts-pattern 같은 패턴매칭 라이브러리로의 확장

까지 빠르게 정리합니다.


1) never 타입 한 줄 정의

이 성질을 이용하면 “이 분기(default)에 들어오는 값은 논리적으로 불가능해야 한다”를 컴파일러에게 증명하게 만드는 게 가능합니다.


2) Exhaustive switch guard – 스니펫 해설

switch (e.do) {
  case 'CONFIRM_RESERVATION':
    this.logger.debug(inspect(e));
    break;
  case 'SCHEDULE_REMINDER':
    this.logger.debug(inspect(e));
    break;
  case 'EMIT_OUTBOX':
    this.logger.debug(inspect(e));
    await this.message.sendNotification(e.payload);
    break;
  case 'RELEASE_PROVISIONAL_RESERVATION':
    this.logger.debug(inspect(e));
    break;
  case 'CANCEL_REMINDER':
    this.logger.debug(inspect(e));
    break;
  default:
    const exhaustiveSwitch: never = e;
    console.log(exhaustiveSwitch);
    throw new RacketimeException(
      'PAYMENT_SESSION',
      'NOT_ALL_EFFECTS_HANDLED',
      e,
    );
}

어떻게 “빠뜨린 case”를 막을까?

즉, 새로운 효과 타입을 추가했는데 switch에 반영하지 않으면 빌드가 깨지게 됩니다. 운영 중 “핸들 안 된 효과”가 조용히 유실되는걸 사전에 차단합니다.


3) 실무 친화적 변형: assertNever() 헬퍼

default에 로깅/예외를 포함하는 대신, 재사용 가능한 헬퍼로 분리하면 더 깔끔합니다.

function assertNever(x: never, message?: string): never {
  // 런타임 안전망: 혹시 컴파일러가 놓치거나 any가 새어들면 알람
  throw new Error(message ?? `Unexpected object: ${JSON.stringify(x)}`);
}

// 사용 예
switch (e.do) {
  case 'CONFIRM_RESERVATION': /* ... */ break;
  case 'SCHEDULE_REMINDER': /* ... */ break;
  case 'EMIT_OUTBOX': /* ... */ break;
  case 'RELEASE_PROVISIONAL_RESERVATION': /* ... */ break;
  case 'CANCEL_REMINDER': /* ... */ break;
  default:
    assertNever(e, 'NOT_ALL_EFFECTS_HANDLED');
}

NestJS/DDD 맥락에서의 팁


4) 유니온 축소(discriminated union)로 안전하게 분기

e.do를 태그 필드로 쓰는 **식별 합집합(Discriminated Union)**은 switch와 궁합이 탁월합니다.

type ConfirmReservation = { do: 'CONFIRM_RESERVATION'; payload: { /* ... */ } };
type EmitOutbox         = { do: 'EMIT_OUTBOX'; payload: { to: string; text: string } };
type Effect = ConfirmReservation | EmitOutbox /* | ... */;

function handleEffect(e: Effect) {
  switch (e.do) {
    case 'CONFIRM_RESERVATION':
      // e.payload는 ConfirmReservation의 payload로 좁혀짐
      return;
    case 'EMIT_OUTBOX':
      // e.payload.to, e.payload.text 안전 접근
      return;
    default:
      assertNever(e);
  }
}

장점:


5) 문자열 리터럴 보존: as constsatisfies

문자열이 단순 string으로 확장되면 exhaustiveness가 깨집니다. 데이터 정의 시 리터럴을 고정하세요.

// ✅ 리터럴 고정
const EFFECTS = [
  'CONFIRM_RESERVATION',
  'SCHEDULE_REMINDER',
  'EMIT_OUTBOX',
  'RELEASE_PROVISIONAL_RESERVATION',
  'CANCEL_REMINDER',
] as const;

type EffectDo = (typeof EFFECTS)[number];

또는 satisfies로 구조는 느슨하게, 키는 정확하게:

const handlers = {
  CONFIRM_RESERVATION: (e: ConfirmReservation) => { /* ... */ },
  EMIT_OUTBOX: (e: EmitOutbox) => { /* ... */ },
  // ...
} satisfies Record<EffectDo, (e: Effect) => Promise<void> | void>;

satisfies타입 체크만 하고 값은 변환하지 않으므로 런타임 오버헤드가 없습니다.


6) switch 대신 “테이블 매핑 + assertNever”도 가능

로직이 함수형으로 더 어울릴 때는 매핑 테이블을 써도 됩니다.

type Handler<T extends Effect> = (e: T) => Promise<void> | void;

const handlerMap: Record<EffectDo, Handler<any>> = {
  CONFIRM_RESERVATION: (e: ConfirmReservation) => { /* ... */ },
  SCHEDULE_REMINDER:   (e) => { /* ... */ },
  EMIT_OUTBOX:         (e: EmitOutbox) => this.message.sendNotification(e.payload),
  RELEASE_PROVISIONAL_RESERVATION: (e) => { /* ... */ },
  CANCEL_REMINDER:     (e) => { /* ... */ },
};

export function handleEffect(e: Effect) {
  const handler = handlerMap[e.do];
  if (!handler) assertNever(e); // 누락 시 컴파일/런타임 모두 잡음
  return handler(e as never);
}

장점:


7) enum에서도 동일하게 적용

리터럴 유니온 대신 enum을 쓴다면:

export enum EffectDoEnum {
  CONFIRM_RESERVATION,
  SCHEDULE_REMINDER,
  EMIT_OUTBOX,
  RELEASE_PROVISIONAL_RESERVATION,
  CANCEL_REMINDER,
}

type Effect =
  | { do: EffectDoEnum.CONFIRM_RESERVATION; /* ... */ }
  | { do: EffectDoEnum.SCHEDULE_REMINDER;   /* ... */ }
  // ...

switch (e.do) {
  case EffectDoEnum.CONFIRM_RESERVATION: /* ... */ break;
  // ...
  default:
    assertNever(e);
}

숫자 enum은 연산에 쓰이기 쉬운 반면, 문자열 리터럴 유니온은 직관적 디버깅/직렬화에 유리합니다. 이벤트/메시지 타입은 보통 문자열 리터럴을 추천합니다.


8) 패턴 매칭 스타일 (ts-pattern)로 더 간결하게

복잡한 가드/중첩 분기를 깔끔하게 쓰고 싶다면 ts-pattern을 고려하세요.

import { match, P } from 'ts-pattern';

match(e)
  .with({ do: 'EMIT_OUTBOX' }, ({ payload }) => this.message.sendNotification(payload))
  .with({ do: 'CONFIRM_RESERVATION' }, () => { /* ... */ })
  .with({ do: 'SCHEDULE_REMINDER' }, () => { /* ... */ })
  .with({ do: 'RELEASE_PROVISIONAL_RESERVATION' }, () => { /* ... */ })
  .with({ do: 'CANCEL_REMINDER' }, () => { /* ... */ })
  .exhaustive(); // ❗ 누락 시 컴파일 에러

장점:


9) 실무에서 흔한 함정과 체크리스트


10) 도메인 이벤트/이펙트에 바로 적용하기 (NestJS 예시)

@Injectable()
export class EffectHandler {
  constructor(private readonly message: MessageService, private readonly logger: Logger) {}

  async handle(e: Effect) {
    switch (e.do) {
      case 'CONFIRM_RESERVATION':
      case 'SCHEDULE_REMINDER':
      case 'RELEASE_PROVISIONAL_RESERVATION':
      case 'CANCEL_REMINDER':
        this.logger.debug(inspect(e));
        return;

      case 'EMIT_OUTBOX':
        this.logger.debug(inspect(e));
        await this.message.sendNotification(e.payload);
        return;

      default:
        // 컴파일 타임: 누락 케이스가 있으면 에러
        // 런타임: any/IO 누수 대비 이중 안전망
        assertNever(e, 'NOT_ALL_EFFECTS_HANDLED');
    }
  }
}

마무리